import sys
import os
sys.path.append(os.getcwd())
from witin_nn import GlobalConfigFactory, LayerConfigFactory
import witin_nn
from witin_nn import WitinConv2d, WitinBatchNorm2d, WitinGELU, WitinLinear

import torch
import torchvision
import torch.nn.functional as F
import torch.nn as nn
import torchvision.transforms as transforms
import torch.optim as optim


# ResNet50 block
class Bottleneck(nn.Module):
    expansion = 4
    
    def __init__(self, in_channels, out_channels, stride=1, downsample=None):
        super(Bottleneck, self).__init__()
        config_conv1 = LayerConfigFactory.get_default_config()
        config_conv1.use_quantization = True
        config_conv1.x_quant_bit = 8
        config_conv1.y_quant_bit = 8
        config_conv1.scale_x = 16
        config_conv1.scale_y = 16
        config_conv1.scale_weight = 16
        config_conv1.use_auto_scale = True
        self.conv1 = WitinConv2d(in_channels, out_channels, kernel_size=1, bias=False, layer_config=config_conv1)

        config_conv2 = LayerConfigFactory.get_default_config()
        config_conv2.use_quantization = True
        config_conv2.x_quant_bit = 8
        config_conv2.y_quant_bit = 8
        config_conv2.scale_x = 16
        config_conv2.scale_y = 16
        config_conv2.scale_weight = 16
        config_conv2.use_auto_scale = True
        self.conv2 = WitinConv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False, layer_config=config_conv2)

        config_conv3 = LayerConfigFactory.get_default_config()
        config_conv3.use_quantization = True
        config_conv3.x_quant_bit = 8
        config_conv3.y_quant_bit = 8
        config_conv3.scale_x = 16
        config_conv3.scale_y = 16
        config_conv3.scale_weight = 16
        config_conv3.use_auto_scale = True
        self.conv3 = WitinConv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False, layer_config=config_conv3)

        config_bn1 = LayerConfigFactory.get_default_config()
        config_bn1.use_quantization = True
        config_bn1.x_quant_bit = 8
        config_bn1.y_quant_bit = 8
        config_bn1.scale_x = 16
        config_bn1.scale_y = 16
        self.bn1 = WitinBatchNorm2d(out_channels, layer_config=config_bn1)

        config_bn2 = LayerConfigFactory.get_default_config()
        config_bn2.use_quantization = True
        config_bn2.x_quant_bit = 8
        config_bn2.y_quant_bit = 8
        config_bn2.scale_x = 16
        config_bn2.scale_y = 16
        self.bn2 = WitinBatchNorm2d(out_channels, layer_config=config_bn2)

        config_bn3 = LayerConfigFactory.get_default_config()
        config_bn3.use_quantization = True
        config_bn3.x_quant_bit = 8
        config_bn3.y_quant_bit = 8
        config_bn3.scale_x = 16
        config_bn3.scale_y = 16
        self.bn3 = WitinBatchNorm2d(out_channels * self.expansion, layer_config=config_bn3)

        config_gelu = LayerConfigFactory.get_default_config()
        config_gelu.use_quantization = True
        config_gelu.x_quant_bit = 8
        config_gelu.y_quant_bit = 8
        config_gelu.scale_x = 16
        config_gelu.scale_y = 16
        self.gelu = WitinGELU(layer_config=config_gelu)
        self.downsample = downsample
    
    def forward(self, x):
        identity = x
        if self.downsample is not None:
            identity = self.downsample(x)
        
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.gelu(out)
        out = self.conv2(out)
        out = self.bn2(out)
        out = self.gelu(out)
        out = self.conv3(out)
        out = self.bn3(out)
        
        out += identity
        out = self.gelu(out)
        return out


# ResNet50 Model
class ResNet(nn.Module):
    def __init__(self, block, layers, num_classes=1000):
        super(ResNet, self).__init__()
        self.in_channels = 64
        config_conv1 = LayerConfigFactory.get_default_config()
        config_conv1.use_quantization = True
        config_conv1.x_quant_bit = 8
        config_conv1.y_quant_bit = 8
        config_conv1.scale_x = 16
        config_conv1.scale_y = 16
        config_conv1.scale_weight = 16
        config_conv1.use_auto_scale = True
        self.conv1 = WitinConv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False, layer_config=config_conv1)

        config_bn1 = LayerConfigFactory.get_default_config()
        config_bn1.use_quantization = True
        config_bn1.x_quant_bit = 8
        config_bn1.y_quant_bit = 8
        config_bn1.scale_x = 16
        config_bn1.scale_y = 16
        self.bn1 = WitinBatchNorm2d(64, layer_config=config_bn1)

        config_gelu = LayerConfigFactory.get_default_config()
        config_gelu.use_quantization = False
        config_gelu.x_quant_bit = 8
        config_gelu.y_quant_bit = 8
        config_gelu.scale_x = 16
        config_gelu.scale_y = 16
        self.gelu = WitinGELU(layer_config=config_gelu)

        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        
        self.layer1 = self._make_layer(block, 64, layers[0])
        self.layer2 = self._make_layer(block, 128, layers[1], stride=2)
        self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
        self.layer4 = self._make_layer(block, 512, layers[3], stride=2)
        
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        config_fc = LayerConfigFactory.get_default_config()
        config_fc.use_quantization = False
        config_fc.x_quant_bit = 8
        config_fc.y_quant_bit = 8
        config_fc.scale_x = 16
        config_fc.scale_y = 16
        config_fc.scale_weight = 16
        config_fc.use_auto_scale = True
        self.fc = WitinLinear(512 * block.expansion, num_classes, bias=False, layer_config=config_fc)
        
    def _make_layer(self, block, out_channels, blocks, stride=1):
        downsample = None
        if stride != 1 or self.in_channels != out_channels * block.expansion:
            config_downsample = LayerConfigFactory.get_default_config()
            config_downsample.use_quantization = True
            config_downsample.x_quant_bit = 8
            config_downsample.y_quant_bit = 8
            config_downsample.scale_x = 16
            config_downsample.scale_y = 16
            config_downsample.scale_weight = 16
            config_downsample.use_auto_scale = True
            downsample = nn.Sequential(
                WitinConv2d(self.in_channels, out_channels * block.expansion, kernel_size=1, stride=stride, bias=False, layer_config=config_downsample),
                WitinBatchNorm2d(out_channels * block.expansion, layer_config=config_downsample),
            )
        
        layers = []
        layers.append(block(self.in_channels, out_channels, stride, downsample))
        self.in_channels = out_channels * block.expansion
        for _ in range(1, blocks):
            layers.append(block(self.in_channels, out_channels))
        
        return nn.Sequential(*layers)
    
    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.gelu(x)
        x = self.maxpool(x)
        
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        
        x = self.avgpool(x)
        x = torch.flatten(x, 1)
        x = self.fc(x)
        
        return x


# Data transformations
myTransforms = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.ToTensor(),
    transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))
])

# Load CIFAR-10 dataset
train_dataset = torchvision.datasets.CIFAR10(root='../data/CIFAR-10', train=True, download=True, transform=myTransforms)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=0)

test_dataset = torchvision.datasets.CIFAR10(root='../data/CIFAR-10', train=False, download=True, transform=myTransforms)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=4, shuffle=True, num_workers=0)

# Define model
myModel = ResNet(Bottleneck, [3, 4, 6, 3], num_classes=10)

# Move model to GPU if available
myDevice = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
myModel = myModel.to(myDevice)

# Define loss function and optimizer
learning_rate = 0.001
myOptimzier = optim.SGD(myModel.parameters(), lr=learning_rate, momentum=0.9)
myLoss = torch.nn.CrossEntropyLoss()

# Train model
for epoch in range(20):  # Train for 20 epochs
    myModel.train()  # Set model to training mode
    training_loss = 0.0
    for step, input_data in enumerate(train_loader):
        image, label = input_data[0].to(myDevice), input_data[1].to(myDevice)
        predict_label = myModel.forward(image)
        
        loss = myLoss(predict_label, label)
        myOptimzier.zero_grad()
        loss.backward()
        myOptimzier.step()
        
        training_loss += loss.item()
        if step % 10 == 0:
            print('[Epoch %d, Iteration %5d] training loss: %.3f' % (epoch + 1, step + 1, training_loss / 10))
            training_loss = 0.0

    # Test the model after each epoch
    myModel.eval()  # Set model to evaluation mode
    correct = 0
    total = 0
    with torch.no_grad():  # Disable gradient calculation
        for images, labels in test_loader:
            images, labels = images.to(myDevice), labels.to(myDevice)
            outputs = myModel(images)
            _, predicted = torch.max(outputs.data, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
    
    print('Epoch %d Testing Accuracy: %.3f %%' % (epoch + 1, 100 * correct / total))